feat(runtime): rebuild ACP execution as plugin adapters - #5224
Sun-GLiang wants to merge 2 commits into
Conversation
6a6b3be to
84bbed6
Compare
9767546 to
b278ff0
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Review — comment only
Targeted review of the ACP rebuild. I did not read all ~2.5k lines; I focused on packages/acp-executor-plugin/src/index.ts, packages/antigravity-acp-plugin/src/index.ts, packages/runtime-host/src/server/builtin-external-agent-plugins.ts, the generic boundary changes in packages/runtime/src/plugin-executor-{service,backend}.ts, the host wiring, and the tests.
The boundary design reads well and I think it's the right shape: ACP mechanics stay in one bundle, adapters own only launch policy, and routing/lifecycle stay with #5283's generic executor path. The items below are about robustness and one class of silent failure, not about the architecture.
Issues
1. AcpExecutor marks a conversation permanently "history-only" for every execution failure, not just process loss — packages/acp-executor-plugin/src/index.ts:212 and :215
execute()'s catch calls await this.#lose(session) unconditionally, and #lose (:508) sets session.lost = true, which makes #session() throw acp_history_only for that conversationKey for the rest of the Entry generation. That's correct for genuine process loss (owner.failed, cancel timeout at :499), but it also fires for:
- the 30s
INITIALIZE_TIMEOUT_MS(:59,:283) — a slow first spawn bricks the conversation; acp_config_unavailable/acp_config_invalidfrom#applyInitialConfig— a wrongmodelvalue in the Entry config permanently disables the conversation even after the config is corrected;- a transient
checkedExecutableENOENT while the agent app is being reinstalled; - a caller abort during
#ensureInitialized—#initializeusesAbortSignal.any([signal, timeout])(:283), sostartupSignal.throwIfAborted()on a user stop during the first prompt also lands here.
The follow-up error is also misleading: it reports "history-only because its external process was lost" when no process was ever started. Suggest only calling #lose() when the connection actually failed, and for other errors clearing session.initialization/session.owner so the next prompt can retry.
2. Agent-authored tool metadata and >64 diffs throw inside context.emit, and the ACP SDK swallows the error — packages/acp-executor-plugin/src/index.ts:421-470, packages/runtime/src/plugin-executor-service.ts:507
PluginExecutorService's emit wrapper calls normalizeOutputEvent outside its try/catch (plugin-executor-service.ts:296-306), so a validation failure throws back into #acceptTool, which runs inside the ACP session/update notification handler. The SDK's dispatch catches notification-handler errors and only console.error("Error handling notification", …) — the connection stays up, so the failure is silent in the product. Two realistic triggers:
#acceptToolpassesdisplayName: snapshot.titleandname: snapshot.name ?? …straight from the agent.normalizeOutputEventboundsdisplayNameat 8192 chars andnameat 256 chars with no\r\n(plugin-executor-service.ts:423-426). A long agent-authored title makestool_startthrow, and becausetoolUseIdsis only populated on a successfultool_start(plugin-executor-backend.ts#publishOutputEvent), the latertool_resultis dropped too and#closeOptionalOutputnever backstops it — the tool vanishes from the transcript entirely.projectToolResult(:781) bounds the combined diff atMAX_TOOL_RESULT_DIFFbut not the path count, whileisPluginToolResultContentrejectspaths.length > 64(plugin-executor-service.ts:507). One multi-file tool call (a rename across >64 files) makes thetool_resultemit throw;snapshot.terminal = trueis set before the emit (:457), so no latertool_call_updateretries it. The backend then backstops with the synthetic "External executor ended before reporting a tool result" for a tool that actually succeeded.
Suggest clamping displayName/name/paths before crossing the boundary (or degrading >64 paths to the text summary), setting terminal only after a successful emit, and wrapping #acceptUpdate in a try/catch that logs.
3. Retained ACP processes are unbounded — packages/acp-executor-plugin/src/index.ts:149, :255, :226
#sessions only ever grows (set in #session, cleared only in dispose), and every entry keeps a live child process plus its tree. One retained process per Maka conversation key, for the lifetime of the Entry generation, with no idle eviction, cap, or LRU. A long-running Host will accumulate one Antigravity process per conversation ever started. Either bound it (evict idle sessions — the continuity marker already encodes the "history-only" consequence) or state the limit explicitly in the README so operators know.
4. No authentication handling on the execution path — packages/acp-executor-plugin/src/index.ts:579
child.stderr is drained to nowhere and initializeResponse.authMethods is ignored (the SDK exposes it at dist/schema/zod.gen.js:1134, along with an authenticate method). The setup path merged in #5164 does parse the auth line out of stderr (packages/runtime-host/src/server/acp/antigravity.ts:144-160). If the saved Antigravity login expires, the first prompt fails as acp_execution_failed / "ACP execution failed" with no re-auth affordance and nothing logged. At minimum, surface authMethods as a distinct code so the Desktop can route back into the existing setup flow.
5. A plugin-projection failure requests a Host drain — packages/runtime-host/src/server/execution-composition.ts:1962-1967
applyRuntimePolicyMutationEffects now runs builtinExternalAgentPlugins.reconcile() inside the existing context.requestDrain(); throw error; path. The setting is already committed by then, and HostPluginPlatform already records the failure and schedules its own reconcile (#recordPackageFailure / #scheduleReconcile), so draining the whole Host because a derived, replaceable package layer failed to install seems heavier than needed. Worth confirming this is intended.
Nits
- Dropped
SessionUpdatekinds —#acceptUpdate(:405) handles 4 of the 14 kinds in the SDK union.config_option_updateis dropped, so the in-memorysession.configOptionsgoes stale once the agent changes a value mid-session (relevant to Set B);plan/plan_updateare dropped as well. A debug log for dropped/unknown kinds would make agent behavior diagnosable. - Diagnostics are discarded —
errorCode(:879) collapses every non-AcpRuntimeErrortoacp_execution_failedandsafeErrorMessage(:886) to'ACP execution failed', with nocauseand no log line. This makes issues 1 and 4 very hard to diagnose in the field. - String-sniffed error channel —
active.text.trimStart().startsWith('Agent execution error:')(:200) treats model-authored transcript text as a failure signal. A response that legitimately begins with that phrase fails the turn. Prefer an explicit stop reason /_metasignal if Antigravity exposes one. supportsAttachmentsis static — the initialize response already reportsagentCapabilities.promptCapabilities(image/audio/embeddedContext). Deriving support from the negotiated capabilities would avoid failing a whole turn withacp_attachments_unsupportedfor agents that do accept images.- Prompt flattening —
promptTextfolds instructions, quotes, and directory references into prose prefixes inside onetextblock. ACPContentBlocksupportsresource_link/resource/image, which would carry that structure instead of text the agent may act on. Relatedly,session/newsendsmcpServers: [], so ACP agents get none of Maka's MCP servers — worth stating that explicitly. dispose()throwing breaks teardown —terminate()throws'ACP process cleanup failed'if the child is still alive at the deadline, anddispose()aggregates that, so a stuck child makes the fiber's effect cleanup fail during plugin uninstall/reload. Consider logging and continuing. (terminatealso has no finalclose/exitawait, so a child exiting just past the last poll is a false positive.)createWholeFileDiff(:812) emits a single whole-file hunk with no\ No newline at end of filemarker, so a diff whoseoldText/newTextlacks a trailing newline is technically malformed for strict patch consumers.- Bundled host runtime code —
package.jsonlists@maka/runtimeas a devDependency, butindex.tsvalue-importsterminateChildProcessTreefrom@maka/runtime/process-tree-terminator, so that implementation is esbuild-bundled intodist/plugin.mjs. That's consistent with the deliberate "no cross-bundleinstanceof" isolation, but it means the shipped bundle carries its own copy that won't track@maka/runtime. Worth a line in the README. - Docs self-reference —
docs/antigravity-acp-plugin-rebuild.mdopens with "Why PR #5224 cannot be carried forward unchanged" / "PR #5224 predates #5283", but this is PR #5224, so the doc reads as arguing against itself. Naming it "the pre-#5283 revision of this PR" would fix it. - Non-hosted permission denial —
PluginExecutorBackend.#requestPermission(plugin-executor-backend.ts:186) returns{ outcome: 'cancelled' }wheneverinput.hostedInteractionis absent, so every ACP permission request is denied for CLI/API/scheduled execution. Safe default, but worth documenting since it silently narrows what external agents can do headlessly.
Verified / no action needed
- Recovery ordering is correct.
builtin-external-agent-pluginsis registered afterplugin-platformin the module array (execution-composition.ts:2504, platform module ~:2494), andrecoverRuntimeHostDomainModulesiterates in array order, sorecover()'spackageProjections()/installPackage()hit a platform where#assertReadable()passes. Good — I checked this specifically because the coordinator depends on platform recovery. - Digest idempotency holds.
extensionPackageDirectoryContentDigestuses the same sorted-path + content hash asPluginPackageStore.decodePackage, andprepareInstallcopies into its own transaction directory before the staging dir is disposed, so the "no generation churn on restart" claim is sound (andbuiltin-external-agent-plugins.test.tsasserts a stableauthorityEpoch). - Packaging.
dependenciesis inWORKSPACE_RELEASE_MANIFEST_FIELDS, so@maka/acp-executor-plugin/@maka/antigravity-acp-pluginreach the packaged app through runtime-host's production closure (files: ["dist"]includesplugin.mjs). - Adapter isolation.
isolate: { acp: true }gives theacp-runtimeEntry its ownacplabel that children inherit;ctx.provide('acp', …)is on the runtime Entry's Context and the adapter passes its own Context explicitly, soexecutors.registerscopes to the consumer Entry (PluginExecutorService.registerreadsthis.ctx, whichService._bindrebinds per consumer). - Cancellation and teardown.
#awaitPromptsendssession/cancel, waits for settlement, and force-terminates only on the 15s timeout;PluginExecutorService's retirement path aborts active executions and awaits settlement, and the effect cleanup disposes the provider (process trees included). - Antigravity launch policy matches the live setup path in
packages/runtime-host/src/server/acp/antigravity.ts(BROWSER=/usr/bin/true,PYTHONUNBUFFERED=1,ANTIGRAVITY_HARNESS_PATH,cwd = dirname(executable),localharness_externalhelper precheck). - Generic boundary changes are additive and validated symmetrically —
PluginExecutorToolResultContentis a bounded discriminated union,file_diffalready exists in the canonicalToolResultEventshape, andnormalizePermissionRequest/normalizePermissionResultvalidate both directions with the form withdrawn on abort. - Concurrency. One prompt per conversation is enforced by
session.active(acp_busy), andsession.initializationde-dupes concurrent initialization.
Test coverage vs. the PR checklist
The checklist says tests cover "lifecycle, continuity, cancellation, permission bridging, diff projection, and adapter registration". acp-executor-plugin.test.ts has 3 tests (retention, history-only, cancellation); permission bridging and file_diff are asserted only incidentally inside the first. The "Behavior and safety" claims with no test: workspace containment including symlink escape for fs/readTextFile / fs/writeTextFile, the 8 MiB file cap, the diff-size degrade path, setConfigOption validation, adapter/config validation, process-tree termination on dispose, and the durable pluginStateStore (only a fake store is exercised). Containment and the diff-degrade path are the two I'd add first, since they're the security/robustness claims.
Coordination
#5283 is the merged base and #5222/#5385/#5386 are CLI-side ACP work that doesn't touch these packages, so I don't see a conflict. The one seam worth aligning is mcpServers: [] above: #5386 adds session-scoped ACP MCP on the CLI side, and the runtime plugin currently opts out entirely. Similarly, this PR's "Set C" (agent questions, unsupported-input presentation) overlaps #5385's interaction mapping — worth a quick sync so the two don't land incompatible contracts.
Summary
This PR has been rebuilt from the latest
main(ea990cab) after #5283 established the generic Plugin-backed Session executor architecture.@maka/acp-executor-plugin, a shared ACP Runtime Plugin that exposesctx.acp.register(ctx, adapter, config)to child Plugin Entries.@maka/antigravity-acp-pluginas a thin Antigravity adapter containing only executable/helper validation, launch environment policy, and optional initial model configuration.PluginExecutorService/PluginExecutorBackend; ACP is not a second backend or Session-routing authority.text | file_difftool results.Refs #5103
Architecture
Runtime layers
flowchart LR Client["Desktop / CLI / API"] subgraph Maka["Maka generic execution authority"] Session["Session Manager<br/>executorId"] Backend["PluginExecutorBackend<br/>canonical event + interaction bridge"] Registry["PluginExecutorService<br/>scoped registry + generation binding"] end subgraph ACPPlugin["ACP Runtime Plugin"] AcpService["ctx.acp<br/>adapter registration"] AcpExecutor["AcpExecutor<br/>protocol + process + Session lifecycle"] end subgraph Adapters["External-Agent adapter plugins"] Antigravity["Antigravity adapter<br/>paths + env + model quirks"] Future["Future ACP adapter<br/>Cursor / other Agent"] end Agent["External ACP process"] Forms["Hosted Form authority"] Transcript["Canonical Session events"] Client -->|"create/send with executorId"| Session Session --> Backend Backend -->|"generation-pinned execute"| Registry Registry --> AcpExecutor AcpService -->|"wrap adapter as executor"| AcpExecutor Antigravity -->|"ctx.acp.register(ctx, adapter, config)"| AcpService Future -->|"ctx.acp.register(ctx, adapter, config)"| AcpService AcpExecutor <-->|"ACP over stdio"| Agent AcpExecutor -->|"PluginExecutorOutputEvent"| Backend Backend --> Transcript AcpExecutor -->|"requestPermission"| Backend Backend <--> FormsThe key boundary is the generic executor contract. Maka owns routing, binding, canonical persistence, and hosted interactions; the ACP Runtime Plugin owns ACP mechanics; each adapter owns only product-specific launch/configuration differences.
Setup-to-plugin activation
RuntimePolicy remains the durable owner of setup facts. The coordinator creates replaceable derived Plugin packages in dependency order and compares canonical content digests before installation, so restart and repeated reconciliation do not churn Plugin generations. Clearing the setting removes the adapter first and then the shared runtime. Neither Plugin bundle receives RuntimePolicy authority.
Plugin composition and ownership
The Host installs
acp-executoras a system-managed package and contributes an isolatedacp-runtimeprofile Entry. External-Agent Entries are mounted below it, so service availability and disposal follow normal Plugin Context/Fiber ownership. The adapter passes its consuming Context explicitly across independently bundled generations; executor registration remains scoped, transactional, generation-pinned, and retired by #5283's existing machinery.One request lifecycle
sequenceDiagram participant User participant Session as Maka Session participant Backend as PluginExecutorBackend participant Service as PluginExecutorService participant ACP as ACP Runtime participant Agent as External ACP Agent User->>Session: send(turn, executorId) Session->>Backend: BackendSendInput Backend->>Service: execute(binding, request) Service->>ACP: execute(request, context) alt first prompt for this conversation ACP->>Agent: spawn + initialize ACP->>Agent: session/new(cwd) ACP->>Agent: setConfigOption (optional) ACP->>ACP: persist continuity marker end ACP->>Agent: session/prompt Agent-->>ACP: text / thought / tool updates ACP-->>Backend: generic output events Backend-->>Session: canonical SessionEvents opt Agent requests permission Agent->>ACP: session/requestPermission ACP->>Backend: context.requestPermission Backend->>User: Hosted Form User-->>Backend: selected / cancelled Backend-->>ACP: validated result ACP-->>Agent: ACP permission outcome end Agent-->>ACP: stopReason ACP-->>Service: completed / cancelled / failed Service-->>Backend: normalized terminal result Backend-->>Session: complete / abort / errorCancellation flows in the reverse direction through the same chain: Session stop aborts the bound executor call, ACP sends
session/cancel, waits for settlement, and force-terminates the process tree only when cooperative cleanup does not finish. Plugin disable/reload/uninstall uses the same retirement and drain path.Responsibility boundary
executorIdrouting, scoped registration, generation binding, canonical events, Hosted FormsThe Antigravity adapter build is about 2.4 KB; the ACP SDK and shared lifecycle implementation live only in the runtime package. A future ACP provider implements the adapter contract instead of copying process, protocol, file, permission, cancellation, and event-projection code.
Removed from the previous implementation
The rebuild deliberately does not carry forward the old PR's:
AcpAgentBackendand ACP backend registry;backend: "acp"/externalAgentIdSession and Storage branches;Those paths either duplicate #5283 or require a future generic executor configuration/selection capability. The installation and authentication foundation already merged in #5164 remains unchanged because it is still live mainline behavior.
Behavior and safety
session/new, prompt, cancellation, and cleanup are shared across adapters.file_difftool results; oversized diffs degrade to a bounded summary.Remaining PR 2 work
PR 2 remains one vertical PR. Work is tracked as four producer-to-consumer minimum sets inside this same PR:
A checklist group is complete only when its producer, boundary contract, real consumer, and acceptance test land together. Dynamic modes and catalog invalidation remain PR 4; restoring the same external Session remains PR 3.
Verification
Current head:
169b969dfplugin.mjsinstall/restart/config-clear testgit diff --checkand staged ASF header guardAI use
Select exactly one:
Tool(s) and scope: OpenAI Codex contributed architecture analysis, implementation, tests, verification, documentation, and PR preparation. Independent human review remains required.
Checklist
main